Skip to content

feat(desktop): add pan, pinch, and zoom to the image file pane - #6285

Merged
Kitenite merged 3 commits into
mainfrom
pan-pinch-zoom-images
Aug 9, 2026
Merged

Kitenite merged 3 commits into
mainfrom
pan-pinch-zoom-images

Conversation

@Kitenite

@Kitenite Kitenite commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

Summary

  • The v2 image file pane (FilePane registry ImageView) now supports pinch-to-zoom, scroll/drag panning, and double-click reset, instead of a fixed fit-to-pane image.

Why / Context

Images opened in a workspace pane were locked to a fitted view with no way to inspect details (screenshots, diagrams, dense UI captures). This adds standard image-viewer navigation gestures.

How It Works

  • A {scale, x, y} transform is applied to the image wrapper; zoom is anchored at the cursor (zoomAtPoint), clamped to 25%–1600%.
  • Pinch: Chromium delivers trackpad pinch as a wheel event with ctrlKey set, so ctrl/cmd+wheel zooms. The listener is attached natively as non-passive because React's onWheel can't preventDefault (needed to stop scroll chaining/page zoom).
  • Pan: plain two-finger scroll pans via wheel deltas; click-drag pans via pointer capture (survives leaving the pane mid-drag).
  • Reset: double-click, or a zoom-percentage badge that appears bottom-right whenever the view is zoomed/panned.
  • The transform resets when the pane switches to a different file, and the container div now always mounts (the previous objectUrl early return would have left the once-mounted wheel listener unattached).

Only the v2 pane registry view is changed; the sunsetting v1 FileViewerPane is untouched.

Manual QA Checklist

  • Open an image file in a workspace pane; it renders fitted as before
  • Trackpad pinch zooms around the cursor; ctrl/cmd + mouse wheel does the same
  • Two-finger scroll pans; click-drag pans with grab cursor
  • Zoom badge appears when zoomed; clicking it or double-clicking resets to fit
  • Switching the pane to another image resets the view
  • Drag released outside the pane doesn't leave panning stuck

Testing

  • bun run lint
  • bun run typecheck (desktop)
  • Not manually QA'd in the running app — gesture behavior (pinch/pan/reset) needs a trackpad pass per the checklist above.

Summary by cubic

Adds pan, pinch-to-zoom, keyboard zoom/pan, and quick reset to the desktop image file pane so you can inspect screenshots and diagrams in place. Also extracts a reusable usePanZoom hook for future viewers.

  • New Features

    • Pinch or Ctrl/Cmd + wheel zooms at the cursor (25%–1600%).
    • Two-finger scroll and click-drag to pan, with grab cursor.
    • Double-click or use the zoom badge to reset to fit.
    • View resets when switching files.
    • Applies to the v2 FilePane ImageView only.
  • Bug Fixes

    • Keyboard pan/zoom now works with focus (+/− to zoom, arrows to pan, 0 to reset) and adds aria hints.
    • Ensures single-pointer drag ownership and prevents reset-badge clicks from starting a drag.
    • Container always mounts so the non-passive wheel listener attaches reliably.
    • Uses pointer capture to avoid stuck panning when the pointer leaves the pane.
    • Prevents scroll chaining and browser zoom via preventDefault on wheel.

Written for commit 4717c1e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added interactive image zooming with bounded controls.
    • Added pointer-based panning and support for wheel and trackpad pinch gestures.
    • Added keyboard controls for zooming, panning, and resetting the view.
    • Added controls to reset the image transform and zoom percentage.
    • Added double-click support to reset the image view.
    • Added visual drag-state feedback while moving images.
    • Image positioning now resets automatically when viewing a different document or file.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

ImageView now supports bounded zooming, pointer-based panning, wheel and pinch gestures, keyboard controls, transform reset actions, drag state, and a zoom percentage indicator.

Changes

Interactive image viewing

Layer / File(s) Summary
Pan and zoom interaction hook
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/usePanZoom.ts
usePanZoom tracks bounded transforms, zooms around the pointer, handles wheel and pinch input, supports pointer dragging with capture, provides keyboard controls, and resets on double-click.
ImageView integration and reset control
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx, apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/index.ts
ImageView connects usePanZoom to the focusable image surface, resets the transform when document content or file paths change, and renders the zoom percentage reset control.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ImageView
  participant usePanZoom
  participant ImageSurface
  User->>ImageSurface: Wheel, pointer, or keyboard input
  ImageSurface->>usePanZoom: Invoke interaction handlers
  usePanZoom->>ImageView: Return transform and drag state
  ImageView->>ImageSurface: Apply scale and translation
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses conventional commit format and clearly describes the main image pane pan, pinch, and zoom changes.
Description check ✅ Passed The description explains the changes, motivation, implementation, testing, and pending manual QA; it is mostly complete despite unchecked checklist items.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pan-pinch-zoom-images

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx:
- Around line 94-135: Update the ImageView pan/zoom surface and its related
controls to provide an always-available keyboard-accessible path for zooming,
panning, and resetting the transform. Add documented keyboard handlers or
persistent accessible controls that invoke the existing transform logic, while
preserving pointer behavior and retaining equivalent keyboard support before
removing the noStaticElementInteractions suppression.
- Around line 101-133: Update the ImageView pointer handlers so an active drag
in dragRef cannot be replaced by another pointer: ignore onPointerDown events
while dragRef.current is set, and in onPointerUp and onPointerCancel clear the
drag state only when the ending event’s pointerId matches the active drag’s
pointerId. Preserve existing movement and dragging-state behavior for the
initiating pointer.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: db8c1a1e-63f0-460c-9dba-3d5dee7eb53a

📥 Commits

Reviewing files that changed from the base of the PR and between 871b02b and 9498120.

📒 Files selected for processing (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/usePanZoom.ts:
- Around line 85-117: Update the usePanZoom drag handlers so only the active
pointer can end the drag: have endDrag receive the pointer event, compare its
pointerId with dragRef.current.pointerId, and ignore mismatches. Ensure
onPointerDown accepts a primary pointer only, preventing additional touches from
replacing the active drag.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e43f3152-49a0-42ea-a901-04b64761a74b

📥 Commits

Reviewing files that changed from the base of the PR and between 9498120 and 9b4dbb6.

📒 Files selected for processing (3)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/usePanZoom.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx

Comment on lines +85 to +117
const endDrag = () => {
dragRef.current = null;
setIsDragging(false);
};

const handlers = {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: transform.x,
originY: transform.y,
};
setIsDragging(true);
},
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
setTransform((prev) => ({
...prev,
x: drag.originX + event.clientX - drag.startX,
y: drag.originY + event.clientY - drag.startY,
}));
},
onPointerUp: endDrag,
onPointerCancel: endDrag,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the active drag when another pointer ends.

onPointerDown accepts every primary-button pointer, and endDrag does not compare pointer IDs. A second touch can replace dragRef.current, then the first touch pointerup can clear the second drag. Accept only the primary pointer and ignore end events from other pointers.

Proposed fix
-	const endDrag = () => {
+	const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
+		const drag = dragRef.current;
+		if (!drag || drag.pointerId !== event.pointerId) {
+			return;
+		}
 		dragRef.current = null;
 		setIsDragging(false);
 	};
 
 	const handlers = {
 		onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => {
-			if (event.button !== 0) {
+			if (event.button !== 0 || !event.isPrimary) {
 				return;
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const endDrag = () => {
dragRef.current = null;
setIsDragging(false);
};
const handlers = {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: transform.x,
originY: transform.y,
};
setIsDragging(true);
},
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
setTransform((prev) => ({
...prev,
x: drag.originX + event.clientX - drag.startX,
y: drag.originY + event.clientY - drag.startY,
}));
},
onPointerUp: endDrag,
onPointerCancel: endDrag,
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
dragRef.current = null;
setIsDragging(false);
};
const handlers = {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !event.isPrimary) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: transform.x,
originY: transform.y,
};
setIsDragging(true);
},
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
setTransform((prev) => ({
...prev,
x: drag.originX + event.clientX - drag.startX,
y: drag.originY + event.clientY - drag.startY,
}));
},
onPointerUp: endDrag,
onPointerCancel: endDrag,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/usePanZoom.ts
around lines 85 - 117, Update the usePanZoom drag handlers so only the active
pointer can end the drag: have endDrag receive the pointer event, compare its
pointerId with dragRef.current.pointerId, and ignore mismatches. Ensure
onPointerDown accepts a primary pointer only, preventing additional touches from
replacing the active drag.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/usePanZoom.ts">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/usePanZoom.ts:85">
P3: endDrag (used for both onPointerUp and onPointerCancel) clears dragRef.current unconditionally without checking the event's pointerId. If a second pointer (e.g., another touch) starts while dragging, it can replace dragRef.current, and then the pointerup/pointercancel from the original pointer will incorrectly end the second drag. Consider checking event.pointerId against dragRef.current.pointerId in endDrag and requiring event.isPrimary in onPointerDown.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

return () => container.removeEventListener("wheel", handleWheel);
}, []);

const endDrag = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: endDrag (used for both onPointerUp and onPointerCancel) clears dragRef.current unconditionally without checking the event's pointerId. If a second pointer (e.g., another touch) starts while dragging, it can replace dragRef.current, and then the pointerup/pointercancel from the original pointer will incorrectly end the second drag. Consider checking event.pointerId against dragRef.current.pointerId in endDrag and requiring event.isPrimary in onPointerDown.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/hooks/usePanZoom/usePanZoom.ts, line 85:

<comment>endDrag (used for both onPointerUp and onPointerCancel) clears dragRef.current unconditionally without checking the event's pointerId. If a second pointer (e.g., another touch) starts while dragging, it can replace dragRef.current, and then the pointerup/pointercancel from the original pointer will incorrectly end the second drag. Consider checking event.pointerId against dragRef.current.pointerId in endDrag and requiring event.isPrimary in onPointerDown.</comment>

<file context>
@@ -0,0 +1,132 @@
+		return () => container.removeEventListener("wheel", handleWheel);
+	}, []);
+
+	const endDrag = () => {
+		dragRef.current = null;
+		setIsDragging(false);
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx:35">
P3: role="application" disables normal screen-reader navigation (virtual cursor) inside the pane, so the image's alt text and the reset button stop being announced/reachable through standard reading keys. tabIndex={0} already supplies the focusable element the keyboard pan/zoom handlers need, so the region can typically be labeled without application — consider a more permissive role (or aria-roledescription on the focusable region) so the inner img/button stay accessible, unless full screen-reader key interception is intentional.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

</div>
<div
ref={containerRef}
role="application"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: role="application" disables normal screen-reader navigation (virtual cursor) inside the pane, so the image's alt text and the reset button stop being announced/reachable through standard reading keys. tabIndex={0} already supplies the focusable element the keyboard pan/zoom handlers need, so the region can typically be labeled without application — consider a more permissive role (or aria-roledescription on the focusable region) so the inner img/button stay accessible, unless full screen-reader key interception is intentional.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx, line 35:

<comment>role="application" disables normal screen-reader navigation (virtual cursor) inside the pane, so the image's alt text and the reset button stop being announced/reachable through standard reading keys. tabIndex={0} already supplies the focusable element the keyboard pan/zoom handlers need, so the region can typically be labeled without application — consider a more permissive role (or aria-roledescription on the focusable region) so the inner img/button stay accessible, unless full screen-reader key interception is intentional.</comment>

<file context>
@@ -32,7 +32,11 @@ export function ImageView({ document, filePath }: ViewProps) {
 		<div
 			ref={containerRef}
-			className={`relative flex h-full touch-none items-center justify-center overflow-hidden bg-background p-4 ${
+			role="application"
+			aria-label={`Image preview of ${getBaseName(filePath)}. Zoom with plus and minus, pan with arrow keys, press 0 to reset.`}
+			// biome-ignore lint/a11y/noNoninteractiveTabindex: focus is required for the keyboard pan/zoom handlers
</file context>

@Kitenite
Kitenite merged commit c661d26 into main Aug 9, 2026
12 checks passed
@Kitenite
Kitenite deleted the pan-pinch-zoom-images branch August 9, 2026 16:55
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch

Thank you for your contribution! 🎉

Kitenite added a commit that referenced this pull request Aug 12, 2026
* docs: weekly update for PRs merged 2026-08-05 to 2026-08-12

- workspaces: sessions (project-less workspaces, #6274), pin + drag-and-drop
  pinning (#6364), list/board views with Merged & Deleted history columns
  (#6296), per-project naming instructions (#6250), multi-PR links per
  branch with remove/restore (#5924)
- automations: session automations (#6280), list health stats + Up next
  grouping + read-only team automations (#6291, #6276), detail page
  Settings/Run History tabs, inline run errors, would-run preview, RRule
  validation (#6305)
- editor: image pane pan/pinch/zoom (#6285)
- tasks: multi-repo filters, expanded task states, PR author/review
  filters, PR checks (#6201)
- monorepos: per-project sparse checkout (#6034)
- customization: project icon + color picker, v1 color carry-over (#6249)
- terminal presets: built-in Superset CLI preset (#6270)
- agents: resume interrupted sessions + Resume Args setting (#6253, #6265),
  Superset hooks toggle + scoping (#6072)
- cli: interactive command browser + branded help (#6273); reference
  updated for session mode, --task, --skip-branch-prefix, --session
  (verified against packages/cli/src/commands)

* docs: tighten wording and drop em dashes

Shorten this week's additions per the writing guide: one thought per
sentence, plainest word, no em dashes as prose punctuation. Also sweeps
the three pre-existing em dashes in recipes.

* docs: add screenshots from the 08-09 changelog batch

cli command browser, agent resume banner, PR view with checks, and the
project icon/color picker. Copied into apps/docs/public/images per the
SOP rather than referenced across apps.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant